Java+Netty+WebRTC、语音、视频、屏幕共享【聊天室设计实践】 您所在的位置:网站首页 webrtc gateway Java+Netty+WebRTC、语音、视频、屏幕共享【聊天室设计实践】

Java+Netty+WebRTC、语音、视频、屏幕共享【聊天室设计实践】

2023-10-07 09:40| 来源: 网络整理| 查看: 265

背景

本文使用webtrc实现了一个简单的语音视频聊天室、支持多人音视频聊天、屏幕共享。

环境配置

音视频功能需要在有Https协议的域名下才能获取到设备信息,

测试环境搭建Https服务参考Windows下Nginx配置SSL实现Https访问(包含openssl证书生成)_殷长庆的博客-CSDN博客

正式环境可以申请一个免费的证书 

复杂网络环境下需要自己搭建turnserver,网络上搜索大多是使用coturn来搭建turn服务 

turn默认监听端口3478,可以使用webrtc.github.io 测试服务是否可用

本文在局域网内测试,不必要部署turn,使用的谷歌的stun:stun.l.google.com:19302

webrtc参考文章

WebRTC技术简介 - 知乎 (zhihu.com)

实现  服务端 

服务端使用netty构建一个websocket服务,用来完成为音视频传递ICE信息等工作。 

maven配置 4.0.0 com.luck.cc cc-im 1.0-SNAPSHOT cc-im http://maven.apache.org ${env.JAVA_HOME} UTF-8 1.8 io.netty netty-all 4.1.74.Final cn.hutool hutool-all 5.5.7 maven-compiler-plugin 1.8 1.8 maven-assembly-plugin 3.0.0 com.luck.im.ServerStart jar-with-dependencies make-assembly package single  JAVA代码

 聊天室服务

package com.luck.im; import java.util.List; import io.netty.bootstrap.ServerBootstrap; import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.ChannelInitializer; import io.netty.channel.ChannelPipeline; import io.netty.channel.EventLoopGroup; import io.netty.channel.nio.NioEventLoopGroup; import io.netty.channel.socket.SocketChannel; import io.netty.channel.socket.nio.NioServerSocketChannel; import io.netty.handler.codec.MessageToMessageCodec; import io.netty.handler.codec.http.HttpServerCodec; import io.netty.handler.codec.http.websocketx.TextWebSocketFrame; import io.netty.handler.codec.http.websocketx.WebSocketServerProtocolHandler; public class ChatSocket { private static EventLoopGroup bossGroup = new NioEventLoopGroup(); private static EventLoopGroup workerGroup = new NioEventLoopGroup(); private static ChannelFuture channelFuture; /** * 启动服务代理 * * @throws Exception */ public static void startServer() throws Exception { try { ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup).channel(NioServerSocketChannel.class) .childHandler(new ChannelInitializer() { @Override public void initChannel(SocketChannel ch) throws Exception { ChannelPipeline pipeline = ch.pipeline(); pipeline.addLast(new HttpServerCodec()); pipeline.addLast( new WebSocketServerProtocolHandler("/myim", null, true, Integer.MAX_VALUE, false)); pipeline.addLast(new MessageToMessageCodec() { @Override protected void decode(ChannelHandlerContext ctx, TextWebSocketFrame frame, List list) throws Exception { list.add(frame.text()); } @Override protected void encode(ChannelHandlerContext ctx, String msg, List list) throws Exception { list.add(new TextWebSocketFrame(msg)); } }); pipeline.addLast(new ChatHandler()); } }); channelFuture = b.bind(8321).sync(); channelFuture.channel().closeFuture().sync(); } finally { shutdown(); // 服务器已关闭 } } public static void shutdown() { if (channelFuture != null) { channelFuture.channel().close().syncUninterruptibly(); } if ((bossGroup != null) && (!bossGroup.isShutdown())) { bossGroup.shutdownGracefully(); } if ((workerGroup != null) && (!workerGroup.isShutdown())) { workerGroup.shutdownGracefully(); } } }

聊天室业务 

package com.luck.im; import java.util.Map; import java.util.concurrent.ConcurrentHashMap; import cn.hutool.json.JSONObject; import cn.hutool.json.JSONUtil; import io.netty.channel.Channel; import io.netty.channel.ChannelHandlerContext; import io.netty.channel.SimpleChannelInboundHandler; import io.netty.util.AttributeKey; import io.netty.util.internal.StringUtil; public class ChatHandler extends SimpleChannelInboundHandler { /** 用户集合 */ private static Map umap = new ConcurrentHashMap(); /** channel绑定自己的用户ID */ public static final AttributeKey UID = AttributeKey.newInstance("uid"); @Override public void channelRead0(ChannelHandlerContext ctx, String msg) { JSONObject parseObj = JSONUtil.parseObj(msg); Integer type = parseObj.getInt("t"); String uid = parseObj.getStr("uid"); String tid = parseObj.getStr("tid"); switch (type) { case 0: // 心跳 break; case 1: // 用户加入聊天室 umap.put(uid, ctx.channel()); ctx.channel().attr(UID).set(uid); umap.forEach((x, y) -> { if (!x.equals(uid)) { JSONObject json = new JSONObject(); json.set("t", 2); json.set("uid", uid); json.set("type", "join"); y.writeAndFlush(json.toString()); } }); break; case 2: Channel uc = umap.get(tid); if (null != uc) { uc.writeAndFlush(msg); } break; case 9: // 用户退出聊天室 umap.remove(uid); leave(ctx, uid); ctx.close(); break; default: break; } } @Override public void channelInactive(ChannelHandlerContext ctx) throws Exception { String uid = ctx.channel().attr(UID).get(); if (StringUtil.isNullOrEmpty(uid)) { super.channelInactive(ctx); return; } ctx.channel().attr(UID).set(null); umap.remove(uid); leave(ctx, uid); super.channelInactive(ctx); } /** * 用户退出 * * @param ctx * @param uid */ private void leave(ChannelHandlerContext ctx, String uid) { umap.forEach((x, y) -> { if (!x.equals(uid)) { // 把数据转到用户服务 JSONObject json = new JSONObject(); json.set("t", 9); json.set("uid", uid); y.writeAndFlush(json.toString()); } }); } @Override public void exceptionCaught(ChannelHandlerContext ctx, Throwable cause) throws Exception { cause.printStackTrace(); ctx.close(); } }

启动类

package com.luck.im; public class ServerStart { public static void main(String[] args) throws Exception { // 启动聊天室 ChatSocket.startServer(); } } 前端

网页主要使用了adapter-latest.js,下载地址webrtc.github.io

github访问不了可以用webrtc/adapter-latest.js-Javascript文档类资源-CSDN文库 

index.html 

聊天室 video{width:100px;height:100px} 开启屏幕共享 关闭屏幕共享 开启视频 关闭视频 开启语音 关闭语音 退出 function getUid(id){ return id?id:uid; } // 开启屏幕共享 function startScreen(id){ id=getUid(id); if(id!=uid){ sendMsg(id,{type:'startScreen'}) return false; } if(!screenVideo.srcObject){ let options = {audio: false, video: true}; navigator.mediaDevices.getDisplayMedia(options) .then(stream => { screenVideo.srcObject = stream; for(let i in remotes){ onmessage({uid:i,t:2,type:'s_join'}); } stream.getVideoTracks()[0].addEventListener('ended', () => { closeScreen(); }); }) } } function selfCloseScreen(ot){ screenVideo.srcObject.getVideoTracks()[0].stop() for(let i in remotes){ sendMsg(i,{type:'closeScreen',ot:ot}) } screenVideo.srcObject=null; } // 关闭屏幕共享 function closeScreen(id,ot){ id=getUid(id); ot=(ot?ot:1); if(id!=uid){ if(ot==1&&remotes[id].screenVideo){ remotes[id].screenVideo.srcObject=null; }else{ sendMsg(id,{type:'closeScreen',ot:2}) } return false; } if(screenVideo.srcObject&&ot==1){ selfCloseScreen(ot) } } // 开启视频 function startVideo(id){ id=getUid(id); if(id!=uid){ sendMsg(id,{type:'startVideo'}) return false; } let v = localVideo.srcObject.getVideoTracks(); if(v&&v.length>0&&!v[0].enabled){ v[0].enabled=true; } } // 关闭视频 function closeVideo(id){ id=getUid(id); if(id!=uid){ sendMsg(id,{type:'closeVideo'}) return false; } let v = localVideo.srcObject.getVideoTracks(); if(v&&v.length>0&&v[0].enabled){ v[0].enabled=false; } } // 开启语音 function startAudio(id){ id=getUid(id); if(id!=uid){ sendMsg(id,{type:'startAudio'}) return false; } let v = localVideo.srcObject.getAudioTracks(); if(v&&v.length>0&&!v[0].enabled){ v[0].enabled=true; } } // 关闭语音 function closeAudio(id){ id=getUid(id); if(id!=uid){ sendMsg(id,{type:'closeAudio'}) return false; } let v = localVideo.srcObject.getAudioTracks(); if(v&&v.length>0&&v[0].enabled){ v[0].enabled=false; } } // 存储通信方信息 const remotes = {} // 本地视频预览 const localVideo = document.querySelector('#localVideo') // 视频列表区域 const videos = document.querySelector('#videos') // 同屏视频预览 const screenVideo = document.querySelector('#screenVideo') // 同屏视频列表区域 const screenVideos = document.querySelector('#screenVideos') // 用户ID var uid = new Date().getTime() + ''; var ws = new WebSocket('wss://127.0.0.1/myim'); ws.onopen = function() { heartBeat(); onopen(); } // 心跳保持ws连接 function heartBeat(){ setInterval(()=>{ ws.send(JSON.stringify({ t: 0 })) },3000); } function onopen() { navigator.mediaDevices .getUserMedia({ audio: true, // 本地测试防止回声 video: true }) .then(stream => { localVideo.srcObject = stream; ws.send(JSON.stringify({ t: 1, uid: uid })); ws.onmessage = function(event) { onmessage(JSON.parse(event.data)); } }) } async function onmessage(message) { if(message.t==9){ onleave(message.uid); } if(message.t==2&&message.type) switch (message.type) { case 'join': { // 有新的人加入就重新设置会话,重新与新加入的人建立新会话 createRTC(message.uid,localVideo.srcObject,1) const pc = remotes[message.uid].pc const offer = await pc.createOffer() pc.setLocalDescription(offer) sendMsg(message.uid, { type: 'offer', offer }) if(screenVideo.srcObject){ onmessage({uid:message.uid,t:2,type:'s_join'}); } break } case 'offer': { createRTC(message.uid,localVideo.srcObject,1) const pc = remotes[message.uid].pc pc.setRemoteDescription(new RTCSessionDescription(message.offer)) const answer = await pc.createAnswer() pc.setLocalDescription(answer) sendMsg(message.uid, { type: 'answer', answer }) break } case 'answer': { const pc = remotes[message.uid].pc pc.setRemoteDescription(new RTCSessionDescription(message.answer)) break } case 'candidate': { const pc = remotes[message.uid].pc pc.addIceCandidate(new RTCIceCandidate(message.candidate)) break } case 's_join': { createRTC(message.uid,screenVideo.srcObject,2) const pc = remotes[message.uid].s_pc const offer = await pc.createOffer() pc.setLocalDescription(offer) sendMsg(message.uid, { type: 's_offer', offer }) break } case 's_offer': { createRTC(message.uid,screenVideo.srcObject,2) const pc = remotes[message.uid].s_pc pc.setRemoteDescription(new RTCSessionDescription(message.offer)) const answer = await pc.createAnswer() pc.setLocalDescription(answer) sendMsg(message.uid, { type: 's_answer', answer }) break } case 's_answer': { const pc = remotes[message.uid].s_pc pc.setRemoteDescription(new RTCSessionDescription(message.answer)) break } case 's_candidate': { const pc = remotes[message.uid].s_pc pc.addIceCandidate(new RTCIceCandidate(message.candidate)) break } case 'startScreen': { startScreen() break } case 'closeScreen': { const ot = message.ot if(ot==1){ closeScreen(message.uid,1) }else{ closeScreen(uid,1) } break } case 'startVideo': { startVideo() break } case 'closeVideo': { closeVideo() break } case 'startAudio': { startAudio() break } case 'closeAudio': { closeAudio() break } default: console.log(message) break } } function removeScreenVideo(id){ if(remotes[id].s_pc){ remotes[id].s_pc.close() if(remotes[id].screenVideo) screenVideos.removeChild(remotes[id].screenVideo) } } function onleave(id) { if (remotes[id]) { remotes[id].pc.close() videos.removeChild(remotes[id].video) removeScreenVideo(id) delete remotes[id] } } function leave() { ws.send(JSON.stringify({ t: 9, uid: uid })); } // socket发送消息 function sendMsg(tid, msg) { msg.t = 2; msg.tid=tid; msg.uid=uid; ws.send(JSON.stringify(msg)) } // 创建RTC对象,一个RTC对象只能与一个远端连接 function createRTC(id,stream,type) { const pc = new RTCPeerConnection({ iceServers: [ { urls: 'stun:stun.l.google.com:19302' } ] }) // 获取本地网络信息,并发送给通信方 pc.addEventListener('icecandidate', event => { if (event.candidate) { // 发送自身的网络信息到通信方 sendMsg(id, { type: (type==1?'candidate':'s_candidate'), candidate: { sdpMLineIndex: event.candidate.sdpMLineIndex, sdpMid: event.candidate.sdpMid, candidate: event.candidate.candidate } }) } }) // 有远程视频流时,显示远程视频流 pc.addEventListener('track', event => { if(type==1){ if(!remotes[id].video){ const video = createVideo() videos.append(video) remotes[id].video=video } remotes[id].video.srcObject = event.streams[0] }else{ if(!remotes[id].screenVideo){ const video = createVideo() screenVideos.append(video) remotes[id].screenVideo=video } remotes[id].screenVideo.srcObject = event.streams[0] } }) // 添加本地视频流到会话中 if(stream){ stream.getTracks().forEach(track => pc.addTrack(track, stream)) } if(!remotes[id]){remotes[id]={}} if(type==1){ remotes[id].pc=pc }else{ remotes[id].s_pc=pc } } function createVideo(){ const video = document.createElement('video') video.setAttribute('autoplay', true) video.setAttribute('playsinline', true) return video } Nginx配置

上面的index.html文件放到D盘根目录下了,然后配置一下websocket

server { listen 443 ssl; server_name mytest.com; ssl_certificate lee/lee.crt; ssl_certificate_key lee/lee.key; ssl_session_cache shared:SSL:1m; ssl_session_timeout 5m; ssl_ciphers HIGH:!aNULL:!MD5; ssl_prefer_server_ciphers on; location / { root d:/; index index.html index.htm index.php; } location /myim { proxy_pass http://127.0.0.1:8321/myim; } } 运行 

java启动

java -jar cc-im.jar

网页访问

https://127.0.0.1/index.html


【本文地址】

公司简介

联系我们

今日新闻

    推荐新闻

    专题文章
      CopyRight 2018-2019 实验室设备网 版权所有